Go 语言全新实验性 JSON API

前言

JavaScript 对象表示法(JSON) 是一种简洁的数据交换格式。大约 15 年前,我们曾经介绍过 Go 语言对 JSON 的支持,当时引入了将 Go 类型序列化为 JSON 数据以及从 JSON 数据反序列化的功能。从那时起,JSON 已经成为互联网上最受欢迎的数据格式。Go 程序广泛地读写 JSON 数据,encoding/json 现在是 Go 语言第 5 大最常用的导入包。

随着时间推移,软件包会根据用户需求不断演进,encoding/json 也不例外。本文将介绍 Go 1.25 中全新的实验性包 encoding/json/v2encoding/json/jsontext,它们带来了期待已久的改进和修复。

本文将论证为什么需要一个全新的主要 API 版本,并概述这些新包的特性,解释如何使用它们。这些实验性包默认情况下不可见,未来的 API 可能会发生变化。

encoding/json 存在的问题

总体而言,encoding/json 运行良好。将任意 Go 类型编组(marshaling)和解组(unmarshaling)为 JSON 的默认表示方式,结合自定义表示的能力,已经证明了其高度的灵活性。然而,自推出以来的这些年里,各种用户发现了许多不足之处。

行为缺陷

encoding/json 存在各种行为缺陷:

  • JSON 语法处理不精确:多年来,JSON 的标准化程度不断提高,以便程序能够正确通信。通常,解码器在拒绝歧义输入方面变得更加严格,以减少两个实现对特定 JSON 值产生不同(成功)解释的可能性。

    • encoding/json 目前接受无效的 UTF-8,而最新的互联网标准(RFC 8259)要求有效的 UTF-8。默认行为应该在存在无效 UTF-8 时报告错误,而不是引入静默数据损坏,这可能会导致下游问题。
    • encoding/json 目前接受具有重复成员名的对象。RFC 8259 没有指定如何处理重复名称,因此实现可以自由选择任意值、合并值、丢弃值或报告错误。重复名称的存在会导致 JSON 值没有普遍认可的含义。这可能被攻击者在安全应用中利用,并且之前已经被利用过(如 CVE-2017-12635)。默认行为应该以安全为重,拒绝重复名称。
  • 泄露切片和映射的空值性:JSON 经常用于与使用不允许将 null 解组为预期为 JSON 数组或对象的数据类型的 JSON 实现的程序通信。由于 encoding/json 将空切片或映射编组为 JSON null,这可能导致其他实现在解组时出错。一项调查 表明,大多数 Go 用户更希望空切片和映射默认编组为空的 JSON 数组或对象。

  • 大小写不敏感的解组:在解组时,JSON 对象成员名通过大小写不敏感的匹配解析为 Go 结构体字段名。这是一个令人意外的默认行为,也是潜在的安全漏洞和性能限制。

  • 方法调用不一致:由于实现细节,在指针接收器上声明的 MarshalJSON 方法被 encoding/json 不一致地调用。虽然被认为是一个错误,但由于太多应用依赖当前行为,这无法修复。

API 缺陷

encoding/json 的 API 可能很棘手或有限制性:

  • 很难正确地从 io.Reader 解组。用户经常写 json.NewDecoder(r).Decode(v),这无法拒绝输入末尾的尾随垃圾数据。

  • 可以在 EncoderDecoder 类型上设置选项,但不能与 MarshalUnmarshal 函数一起使用。同样,实现 MarshalerUnmarshaler 接口的类型无法使用选项,也没有办法将选项传递到调用栈中。例如,Decoder.DisallowUnknownFields 选项在调用自定义 UnmarshalJSON 方法时会失效。

  • CompactIndentHTMLEscape 函数写入 bytes.Buffer 而不是更灵活的 []byteio.Writer。这限制了这些函数的可用性。

性能限制

抛开内部实现细节不谈,公共 API 使其承诺了某些性能限制:

  • MarshalJSONMarshalJSON 接口方法强制实现分配返回的 []byte。此外,语义要求 encoding/json 验证结果是有效的 JSON,并重新格式化以匹配指定的缩进。

  • UnmarshalJSONUnmarshalJSON 接口方法要求提供完整的 JSON 值(没有任何尾随数据)。这强制 encoding/json 完整解析要解组的 JSON 值,以确定其结束位置,然后才能调用 UnmarshalJSON。之后,UnmarshalJSON 方法本身必须再次解析提供的 JSON 值。

  • 缺乏流式处理:尽管 EncoderDecoder 类型操作 io.Writerio.Reader,但它们将整个 JSON 值缓冲在内存中。用于读取单个标记的 Decoder.Token 方法分配开销很大,并且没有相应的写入标记 API。

此外,如果 MarshalJSONUnmarshalJSON 方法的实现递归调用 MarshalUnmarshal 函数,那么性能会变成二次方的。

直接修复 encoding/json 的尝试

引入软件包的新的、不兼容的主要版本是一个重要考虑。如果可能的话,我们应该尝试修复现有软件包。

虽然添加新功能相对容易,但更改现有功能很难。不幸的是,这些问题是现有 API 的固有后果,在 Go 1 兼容性承诺 下实际上无法修复。

我们原则上可以声明单独的名称,如 MarshalV2UnmarshalV2,但这等同于在同一个包内创建并行命名空间。这导致我们选择 encoding/json/v2(以下简称 v2),在这里我们可以在独立的 v2 命名空间内进行这些更改,与 encoding/json(以下简称 v1)形成对比。

encoding/json/v2 的规划

新主要版本 encoding/json 的规划跨越了数年时间。

2020 年末,由于无法修复当前包中的问题,Daniel Martí(encoding/json 的维护者之一)首先起草了他对 假设的 v2 包应该是什么样子 的想法。

另一方面,在之前的 Go Protocol Buffers API 工作之后,Joe Tsai 对 protojson 包 需要使用自定义 JSON 实现感到失望,因为 encoding/json 既不能遵循 Protocol Buffer 规范所需的更严格的 JSON 标准,也不能高效地以流式方式序列化 JSON。

相信更好的 JSON 未来既有益又可实现,Daniel 和 Joe 联手就 v2 进行头脑风暴,并 开始构建原型(初始代码是 Go protobuf 模块中 JSON 序列化逻辑的完善版本)。

随着时间推移,其他几位贡献者(Roger Peppe、Chris Hines、Johan Brandhorst-Satzkorn 和 Damien Neil)通过提供设计审查、代码审查和回归测试加入了这一努力。许多早期讨论在我们的 录制会议会议记录 中公开可见。

这项工作从一开始就是公开的,我们越来越多地让更广泛的 Go 社区参与,首先是 GopherCon 演讲2023 年末发布的讨论2025 年初发布的正式提案,最近 将 encoding/json/v2 作为 Go 实验(在 Go 1.25 中可用),供所有 Go 用户进行更大规模的测试。

v2 的努力已经进行了 5 年,融合了许多贡献者的反馈,也从生产环境的使用中获得了宝贵的经验。值得注意的是,它主要由非谷歌员工开发和推广,证明了 Go 项目是一个协作努力,拥有一个致力于改善 Go 生态系统的繁荣全球社区。

基于 encoding/json/jsontext 构建

在讨论 v2 API 之前,我们先介绍实验性的 encoding/json/jsontext 包,它为 Go 中 JSON 的未来改进奠定了基础。

Go 中的 JSON 序列化可以分解为两个主要组件:

  • 语法功能:关心基于语法的 JSON 处理
  • 语义功能:定义 JSON 值和 Go 值之间的关系

我们使用术语"编码(encode)"和"解码(decode)"来描述语法功能,使用术语"编组(marshal)"和"解组(unmarshal)"来描述语义功能。我们的目标是在纯粹关心编码的功能与编组功能之间提供清晰的区别。

此图提供了这种分离的概述。紫色块代表类型,蓝色块代表函数或方法。箭头的方向大致代表数据流的方向。图的下半部分由 jsontext 包实现,包含仅关心语法的功能,而上半部分由 json/v2 包实现,包含为下半部分处理的语法数据分配语义含义的功能。

jsontext 的基本 API 如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
package jsontext
type Encoder struct { ... }
func NewEncoder(io.Writer, ...Options) *Encoder
func (*Encoder) WriteValue(Value) error
func (*Encoder) WriteToken(Token) error
type Decoder struct { ... }
func NewDecoder(io.Reader, ...Options) *Decoder
func (*Decoder) ReadValue() (Value, error)
func (*Decoder) ReadToken() (Token, error)
type Kind byte
type Value []byte
func (Value) Kind() Kind
type Token struct { ... }
func (Token) Kind() Kind

jsontext 包提供了在语法级别与 JSON 交互的功能,其名称来源于 RFC 8259 第 2 节,其中 JSON 数据的语法字面上被称为 JSON-text。

由于它仅在语法级别与 JSON 交互,因此不依赖 Go 反射。EncoderDecoder 提供了编码和解码 JSON 值和标记的支持。构造函数 接受可变参数选项,这些选项影响编码和解码的特定行为。与 v1 中声明的 EncoderDecoder 类型不同,jsontext 中的新类型避免了语法和语义之间的混淆,并以真正的流式方式操作。

JSON 值是完整的数据单元,在 Go 中表示为 命名的 []byte。它与 v1 中的 RawMessage 相同。JSON 值在语法上由一个或多个 JSON 标记组成。JSON 标记在 Go 中表示为 不透明的 Token 类型,带有构造函数和访问器方法。它类似于 v1 中的 Token,但设计用于在不分配的情况下表示任意 JSON 标记。

为了解决 MarshalJSONUnmarshalJSON 接口方法的基本性能问题,我们需要一种高效的方式来将 JSON 编码和解码为标记和值的流式序列。在 v2 中,我们引入了 MarshalJSONToUnmarshalJSONFrom 接口方法,它们在 EncoderDecoder 上操作,允许方法的实现以纯流式方式处理 JSON。因此,json 包不需要负责验证或格式化 MarshalJSON 返回的 JSON 值,也不需要负责确定提供给 UnmarshalJSON 的 JSON 值的边界。这些责任属于 EncoderDecoder

介绍 encoding/json/v2

基于 jsontext 包,我们现在介绍实验性的 encoding/json/v2 包。它旨在修复前述问题,同时对 v1 包的用户保持熟悉感。我们的目标是,如果直接迁移到 v2,v1 的用法大部分应该操作相同。

在本文中,我们主要涵盖 v2 的高级 API。关于如何使用的示例,我们鼓励读者研究 v2 包中的示例 或阅读 Anton Zhiyanov 涵盖该主题的博客

v2 的基本 API 如下:

1
2
3
4
5
6
7
8
9
package json
func Marshal(in any, opts ...Options) (out []byte, err error)
func MarshalWrite(out io.Writer, in any, opts ...Options) error
func MarshalEncode(out *jsontext.Encoder, in any, opts ...Options) error
func Unmarshal(in []byte, out any, opts ...Options) error
func UnmarshalRead(in io.Reader, out any, opts ...Options) error
func UnmarshalDecode(in *jsontext.Decoder, out any, opts ...Options) error

MarshalUnmarshal 函数的签名与 v1 类似,但接受选项来配置其行为。MarshalWriteUnmarshalRead 函数直接在 io.Writerio.Reader 上操作,避免了仅为了写入或读取此类类型而临时构造 EncoderDecoder 的需要。MarshalEncodeUnmarshalDecode 函数在 jsontext.Encoderjsontext.Decoder 上操作,实际上是前面提到函数的底层实现。

与 v1 不同,选项是每个编组和解组函数的一流参数,大大扩展了 v2 的灵活性和可配置性。v2 中有 多个可用选项,本文不涵盖。

类型指定的自定义

与 v1 类似,v2 允许类型通过满足特定接口来定义自己的 JSON 表示。

1
2
3
4
5
6
7
8
9
10
11
12
13
type Marshaler interface {
MarshalJSON() ([]byte, error)
}
type MarshalerTo interface {
MarshalJSONTo(*jsontext.Encoder) error
}
type Unmarshaler interface {
UnmarshalJSON([]byte) error
}
type UnmarshalerFrom interface {
UnmarshalJSONFrom(*jsontext.Decoder) error
}

MarshalerUnmarshaler 接口与 v1 中的相同。新的 MarshalerToUnmarshalerFrom 接口允许类型使用 jsontext.Encoderjsontext.Decoder 将自己表示为 JSON。这允许选项向下传递到调用栈,因为可以通过 EncoderDecoder 上的 Options 访问器方法检索选项。

参见 OrderedObject 示例 了解如何实现维护 JSON 对象成员顺序的自定义类型。

调用者指定的自定义

在 v2 中,MarshalUnmarshal 的调用者也可以为任何任意类型指定自定义 JSON 表示,其中调用者指定的函数优先于类型定义的方法或特定类型的默认表示。

1
2
3
4
5
6
7
8
9
func WithMarshalers(*Marshalers) Options
type Marshalers struct { ... }
func MarshalFunc[T any](fn func(T) ([]byte, error)) *Marshalers
func MarshalToFunc[T any](fn func(*jsontext.Encoder, T) error) *Marshalers
func WithUnmarshalers(*Unmarshalers) Options
type Unmarshalers struct { ... }
func UnmarshalFunc[T any](fn func([]byte, T) error) *Unmarshalers
func UnmarshalFromFunc[T any](fn func(*jsontext.Decoder, T) error) *Unmarshalers

MarshalFuncMarshalToFunc 构造自定义编组器,可以使用 WithMarshalers 传递给 Marshal 调用,以覆盖特定类型的编组。类似地,UnmarshalFuncUnmarshalFromFunc 支持 Unmarshal 的类似自定义。

ProtoJSON 示例 演示了此功能如何允许所有 proto.Message 类型的序列化由 protojson 包处理。

行为差异

虽然 v2 的目标是与 v1 行为大部分相同,但其行为已经 在某些方面 发生了变化以解决 v1 中的问题,最值得注意的是:

  • v2 在存在无效 UTF-8 时报告错误。
  • v2 在 JSON 对象包含重复名称时报告错误。
  • v2 将空 Go 切片或 Go 映射分别编组为空 JSON 数组或 JSON 对象。
  • v2 使用从 JSON 成员名到 Go 字段名的大小写敏感匹配将 JSON 对象解组为 Go 结构体。
  • v2 重新定义 omitempty 标签选项,如果字段编码为"空"JSON 值(即 null""[]{}),则省略该字段。
  • v2 在尝试序列化 time.Duration 时报告错误,该类型目前 没有默认表示,但提供选项让调用者决定。

对于大多数行为变化,都有结构体标签选项或调用者指定的选项,可以配置行为以在 v1 或 v2 语义下操作,甚至是其他调用者确定的行为。

更多信息请参见 "迁移到 v2"

性能优化

v2 的 Marshal 性能与 v1 大致相当。有时稍快,但有时稍慢。v2 的 Unmarshal 性能明显快于 v1,基准测试显示改进高达 10 倍。

为了获得更大的性能提升,现有的 MarshalerUnmarshaler 实现也应该迁移到实现 MarshalerToUnmarshalerFrom,以便它们可以从以纯流式方式处理 JSON 中受益。例如,Kubernetes 特定服务中 UnmarshalJSON 方法对 OpenAPI 规范的递归解析显著影响了性能(参见 kubernetes/kube-openapi#315),而切换到 UnmarshalJSONFrom 将性能提高了几个数量级。

更多信息,请参见 go-json-experiment/jsonbench 仓库。

追溯改进 encoding/json

我们希望避免在 Go 标准库中有两个独立的 JSON 实现,因此 v1 在底层基于 v2 实现是至关重要的。这种方法有几个好处:

  • 渐进式迁移:v1 或 v2 中的 MarshalUnmarshal 函数表示一组根据 v1 或 v2 语义操作的默认行为。可以指定选项来配置 MarshalUnmarshal 以完全 v1、主要 v1 加少量 v2、v1 或 v2 的混合、主要 v2 加少量 v1,或完全 v2 语义操作。这允许在两个版本的默认行为之间进行渐进迁移。

  • 功能继承:随着向后兼容的功能添加到 v2,它们将固有地在 v1 中可用。例如,v2 添加了对几个新结构体标签选项(如 inlineformat)的支持,以及对 MarshalJSONToUnmarshalJSONFrom 接口方法的支持,这些都更高性能和灵活。当 v1 基于 v2 实现时,它将继承对这些功能的支持。

  • 减少维护负担:维护一个广泛使用的包需要大量努力。通过让 v1 和 v2 使用相同的实现,维护负担得以减少。通常,单个更改将修复错误、提高性能或为两个版本添加功能。不需要将 v2 更改与等效的 v1 更改进行回移。

虽然 v1 的选定部分可能会随着时间的推移被弃用(假设 v2 从实验中毕业),但整个包永远不会被弃用。将鼓励迁移到 v2,但不是必需的。Go 项目不会放弃对 v1 的支持。

体验 jsonv2

encoding/json/jsontextencoding/json/v2 包中的新 API 默认情况下不可见。要使用它们,请在环境中设置 GOEXPERIMENT=jsonv2 或使用 goexperiment.jsonv2 构建标签构建代码。

实验的本质是 API 不稳定,将来可能会发生变化。尽管 API 不稳定,但实现质量很高,并且已被几个主要项目在生产中成功使用。

v1 基于 v2 实现这一事实意味着在 jsonv2 实验下构建时,v1 的底层实现完全不同。在不更改任何代码的情况下,您应该能够在 jsonv2 下运行测试,理论上不应该有任何新的失败:

1
GOEXPERIMENT=jsonv2 go test ./...

基于 v2 的 v1 重新实现旨在在 Go 1 兼容性承诺 范围内提供相同的行为,尽管可能会观察到一些差异,如错误消息的确切措辞。

我们鼓励您在 jsonv2 下运行测试,并 在问题跟踪器上 报告任何回归。

在 Go 1.25 中成为实验是正式将 encoding/json/jsontextencoding/json/v2 采用到标准库的道路上的重要里程碑。然而,jsonv2 实验的目的是获得更广泛的经验。您的反馈将决定我们的下一步行动以及此实验的结果,这可能导致从放弃努力到作为 Go 1.26 稳定包采用的任何结果。

请在 go.dev/issue/71497 上分享